Skip to content

fix(android): prevent crash from double release during navigation#46

Merged
mfazekas merged 1 commit into
mainfrom
mfazekas/fix-expo-navigation-lifecycle
Nov 27, 2025
Merged

fix(android): prevent crash from double release during navigation#46
mfazekas merged 1 commit into
mainfrom
mfazekas/fix-expo-navigation-lifecycle

Conversation

@mfazekas
Copy link
Copy Markdown
Collaborator

The Rive Android SDK's RiveViewLifecycleObserver releases dependencies on fragment lifecycle onDestroy. During React Navigation screen transitions, fragments are destroyed before views are actually disposed, causing release() to be called when refs are already at 0.

Crash:
java.lang.IllegalArgumentException: Failed requirement. at app.rive.runtime.kotlin.controllers.RiveFileController.release()

Fix ports the proven willDispose pattern from rive-react-native:

  • ReactNativeRiveViewLifecycleObserver: overrides onDestroy to skip auto-release, adds explicit dispose() method
  • ReactNativeRiveAnimationView: custom RiveAnimationView that uses our lifecycle observer
  • RiveReactNativeView: adds willDispose flag, only cleans up in onDetachedFromWindow when flag is set
  • RiveViewManager: extends HybridRiveViewManager to call dispose() in onDropViewInstance (when React Native removes the view)

Reproducer:

import { StyleSheet, Text, View, TouchableOpacity } from 'react-native';
import { useRouter } from 'expo-router';
import { Fit, useRiveFile, RiveView } from '@rive-app/react-native';
import { type Metadata } from '../helpers/metadata';

function RiveAnimation() {
  const { riveFile, isLoading, error } = useRiveFile(
    require('../../assets/rive/rating.riv')
  );

  if (isLoading) {
    return (
      <View style={styles.loadingContainer}>
        <Text>Loading...</Text>
      </View>
    );
  }

  if (error || !riveFile) {
    return (
      <View style={styles.errorContainer}>
        <Text>Error: {error ?? 'No file loaded'}</Text>
      </View>
    );
  }

  return <RiveView file={riveFile} fit={Fit.Contain} style={styles.rive} />;
}

export default function NavigationLifecycleTest() {
  const router = useRouter();

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Navigation Lifecycle Test</Text>
      <Text style={styles.description}>
        This screen has a Rive view. Navigate to another Rive screen and back to
        test lifecycle handling.
      </Text>
      <Text style={styles.steps}>
        1. This screen shows a Rive animation{'\n'}
        2. Tap button to go to Events (another Rive screen){'\n'}
        3. Press Back to return here{'\n'}
        {'\n'}
        If the fix works, no crash should occur.
      </Text>

      <RiveAnimation />

      <TouchableOpacity
        style={styles.button}
        onPress={() => router.push('/EventsExample' as any)}
      >
        <Text style={styles.buttonText}>Go to Events Example (has Rive)</Text>
      </TouchableOpacity>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
    padding: 16,
  },
  title: {
    fontSize: 24,
    fontWeight: 'bold',
    marginBottom: 16,
    textAlign: 'center',
  },
  description: {
    fontSize: 16,
    color: '#666',
    marginBottom: 12,
  },
  steps: {
    fontSize: 14,
    color: '#333',
    backgroundColor: '#f5f5f5',
    padding: 16,
    borderRadius: 8,
    marginBottom: 16,
    lineHeight: 22,
  },
  button: {
    backgroundColor: '#6c5ce7',
    padding: 16,
    borderRadius: 8,
    alignItems: 'center',
    marginTop: 16,
  },
  buttonText: {
    color: '#fff',
    fontSize: 16,
    fontWeight: 'bold',
  },
  rive: {
    width: '100%',
    height: 200,
    backgroundColor: '#f0f0f0',
  },
  loadingContainer: {
    width: '100%',
    height: 200,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: '#f0f0f0',
  },
  errorContainer: {
    width: '100%',
    height: 200,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: '#ffebee',
  },
});

NavigationLifecycleTest.metadata = {
  name: 'Navigation Lifecycle Test',
  description: 'Tests Android lifecycle handling during navigation',
} satisfies Metadata;

Fixes crash log:

11-27 07:31:40.555 12547 12547 E AndroidRuntime: FATAL EXCEPTION: main
11-27 07:31:40.555 12547 12547 E AndroidRuntime: Process: com.rive.expoexample, PID: 12547
11-27 07:31:40.555 12547 12547 E AndroidRuntime: java.lang.IllegalArgumentException: Failed requirement.
11-27 07:31:40.555 12547 12547 E AndroidRuntime: 	at app.rive.runtime.kotlin.controllers.RiveFileController.release(RiveFileController.kt:1065)
11-27 07:31:40.555 12547 12547 E AndroidRuntime: 	at app.rive.runtime.kotlin.RiveViewLifecycleObserver.onDestroy(RiveAnimationView.kt:1212)
11-27 07:31:40.555 12547 12547 E AndroidRuntime: 	at androidx.lifecycle.DefaultLifecycleObserverAdapter.onStateChanged(DefaultLifecycleObserverAdapter.kt:29)
11-27 07:31:40.555 12547 12547 E AndroidRuntime: 	at androidx.lifecycle.LifecycleRegistry$ObserverWithState.dispatchEvent(LifecycleRegistry.jvm.kt:313)
11-27 07:31:40.555 12547 12547 E AndroidRuntime: 	at androidx.lifecycle.LifecycleRegistry.backwardPass(LifecycleRegistry.jvm.kt:266)
11-27 07:31:40.555 12547 12547 E AndroidRuntime: 	at androidx.lifecycle.LifecycleRegistry.sync(LifecycleRegistry.jvm.kt:284)
11-27 07:31:40.555 12547 12547 E AndroidRuntime: 	at androidx.lifecycle.LifecycleRegistry.moveToState(LifecycleRegistry.jvm.kt:135)
11-27 07:31:40.555 12547 12547 E AndroidRuntime: 	at androidx.lifecycle.LifecycleRegistry.handleLifecycleEvent(LifecycleRegistry.jvm.kt:119)
11-27 07:31:40.555 12547 12547 E AndroidRuntime: 	at androidx.fragment.app.FragmentViewLifecycleOwner.handleLifecycleEvent(FragmentViewLifecycleOwner.java:100)
11-27 07:31:40.555 12547 12547 E AndroidRuntime: 	at androidx.fragment.app.Fragment.performDestroyView(Fragment.java:3353)
11-27 07:31:40.555 12547 12547 E AndroidRuntime: 	at androidx.fragment.app.FragmentStateManager.destroyFragmentView(FragmentStateManager.java:776)
11-27 07:31:40.555 12547 12547 E AndroidRuntime: 	at androidx.fragment.app.FragmentStateManager.moveToExpectedState(FragmentStateManager.java:338)
11-27 07:31:40.555 12547 12547 E AndroidRuntime: 	at androidx.fragment.app.SpecialEffectsController$FragmentStateManagerOperation.complete(SpecialEffectsController.kt:664)
11-27 07:31:40.555 12547 12547 E AndroidRuntime: 	at androidx.fragment.app.SpecialEffectsController$Operation.completeSpecialEffect(SpecialEffectsController.kt:587)
11-27 07:31:40.555 12547 12547 E AndroidRuntime: 	at androidx.fragment.app.DefaultSpecialEffectsController$SpecialEffectsInfo.completeSpecialEffect(DefaultSpecialEffectsController.kt:774)
11-27 07:31:40.555 12547 12547 E AndroidRuntime: 	at androidx.fragment.app.DefaultSpecialEffectsController$startAnimations$3.onAnimationEnd$lambda$0(DefaultSpecialEffectsController.kt:273)
11-27 07:31:40.555 12547 12547 E AndroidRuntime: 	at androidx.fragment.app.DefaultSpecialEffectsController$startAnimations$3.$r8$lambda$ZytGaoJ8By--dVBbT6zTRvs0sIA(Unknown Source:0)
11-27 07:31:40.555 12547 12547 E AndroidRuntime: 	at androidx.fragment.app.DefaultSpecialEffectsController$startAnimations$3$$ExternalSyntheticLambda0.run(D8$$SyntheticClass:0)
11-27 07:31:40.555 12547 12547 E AndroidRuntime: 	at android.os.Handler.handleCallback(Handler.java:959)
11-27 07:31:40.555 12547 12547 E AndroidRuntime: 	at android.os.Handler.dispatchMessage(Handler.java:100)
11-27 07:31:40.555 12547 12547 E AndroidRuntime: 	at android.os.Looper.loopOnce(Looper.java:232)
11-27 07:31:40.555 12547 12547 E AndroidRuntime: 	at android.os.Looper.loop(Looper.java:317)
11-27 07:31:40.555 12547 12547 E AndroidRuntime: 	at android.app.ActivityThread.main(ActivityThread.java:8699)
11-27 07:31:40.555 12547 12547 E AndroidRuntime: 	at java.lang.reflect.Method.invoke(Native Method)
11-27 07:31:40.555 12547 12547 E AndroidRuntime: 	at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:580)
11-27 07:31:40.555 12547 12547 E AndroidRuntime: 	at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:886)

The Rive Android SDK's RiveViewLifecycleObserver releases dependencies
on fragment lifecycle onDestroy. During React Navigation screen transitions,
fragments are destroyed before views are actually disposed, causing
release() to be called when refs are already at 0.

Crash:
java.lang.IllegalArgumentException: Failed requirement.
at app.rive.runtime.kotlin.controllers.RiveFileController.release()

Fix ports the proven willDispose pattern from rive-react-native:
- ReactNativeRiveViewLifecycleObserver: overrides onDestroy to skip
  auto-release, adds explicit dispose() method
- ReactNativeRiveAnimationView: custom RiveAnimationView that uses
  our lifecycle observer
- RiveReactNativeView: adds willDispose flag, only cleans up in
  onDetachedFromWindow when flag is set
- RiveViewManager: extends HybridRiveViewManager to call dispose()
  in onDropViewInstance (when React Native removes the view)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
@mfazekas mfazekas requested a review from HayesGordon November 27, 2025 11:56
Copy link
Copy Markdown
Contributor

@HayesGordon HayesGordon left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! Hopefully we can avoid this complexity in the new Android runtime

@mfazekas mfazekas merged commit 58df7da into main Nov 27, 2025
5 checks passed
@HayesGordon HayesGordon deleted the mfazekas/fix-expo-navigation-lifecycle branch December 9, 2025 16:24
mfazekas added a commit that referenced this pull request Mar 13, 2026
Upgrades nitrogen CLI and nitro-modules runtime to 0.35.0. Fixes
`ArrayBufferHolder` → `ArrayBuffer` rename and regenerates all nitrogen
bindings.

Nitro 0.35 made generated ViewManagers `final`
([nitro#1181](mrousavy/nitro#1181)), which broke
our `RiveViewManager.onDropViewInstance()` override needed for proper
view lifecycle cleanup
([#46](#46)).
Added a nitrogen postprocess script that patches `HybridRiveViewManager`
back to `open` so we can subclass it again.

Without `onDropViewInstance`, `onDetachedFromWindow` disposes Rive
resources on every navigation (not just permanent removal), causing
state loss. GC-based cleanup was tested and confirmed unreliable —
neither Hermes GC nor JVM GC triggers dispose.

## Added test for navigation lifecycle on onDetachedFromWindow:
Verified that it's broken without the workaround
```diff
   override fun onDetachedFromWindow() {
-    if (willDispose) {
+    if (true || willDispose) {
       riveAnimationView?.dispose()
       removeEventListeners()
     }
```

<img width="330" height="119" alt="image"
src="https://github.com/user-attachments/assets/3c635403-7a89-45bf-84c0-6592c2d234a6"
/>


<details>
<summary>Reproducer: NavigationLifecycle.tsx</summary>

Uses [counter.riv](https://rive.app/community/files/26808-50366-counter)
with a tap counter. Navigate to QuickStart and back; without the fix the
counter resets to 0.

```tsx
import { StyleSheet, Text, View, TouchableOpacity } from 'react-native';
import { useRouter } from 'expo-router';
import { Fit, useRiveFile, RiveView } from '@rive-app/react-native';
import { type Metadata } from '../../shared/metadata';

function RiveGraphic() {
  const { riveFile, isLoading, error } = useRiveFile(
    require('../../../assets/rive/counter.riv')
  );

  if (isLoading) {
    return (
      <View style={styles.loadingContainer}>
        <Text>Loading...</Text>
      </View>
    );
  }

  if (error || !riveFile) {
    return (
      <View style={styles.errorContainer}>
        <Text>Error: {error ?? 'No file loaded'}</Text>
      </View>
    );
  }

  return <RiveView file={riveFile} fit={Fit.Contain} style={styles.rive} />;
}

export default function NavigationLifecycle() {
  const router = useRouter();

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Navigation Lifecycle Test</Text>
      <Text style={styles.description}>
        This screen has a Rive view. Navigate to another Rive screen and back to
        test lifecycle handling.
      </Text>
      <Text style={styles.steps}>
        1. This screen shows Rive graphics{'\n'}
        2. Tap button to go to QuickStart (another Rive screen){'\n'}
        3. Press Back to return here{'\n'}
        {'\n'}
        If the fix works, no crash should occur.
      </Text>

      <RiveGraphic />

      <TouchableOpacity
        style={styles.button}
        onPress={() => router.push('/QuickStart' as any)}
      >
        <Text style={styles.buttonText}>Go to QuickStart (has Rive)</Text>
      </TouchableOpacity>
    </View>
  );
}

NavigationLifecycle.metadata = {
  name: 'Navigation Lifecycle',
  description: 'Tests Android lifecycle handling during navigation (PR #46)',
} satisfies Metadata;

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#fff', padding: 16 },
  title: { fontSize: 24, fontWeight: 'bold', marginBottom: 16, textAlign: 'center' },
  description: { fontSize: 16, color: '#666', marginBottom: 12 },
  steps: { fontSize: 14, color: '#333', backgroundColor: '#f5f5f5', padding: 16, borderRadius: 8, marginBottom: 16, lineHeight: 22 },
  button: { backgroundColor: '#6c5ce7', padding: 16, borderRadius: 8, alignItems: 'center', marginTop: 16 },
  buttonText: { color: '#fff', fontSize: 16, fontWeight: 'bold' },
  rive: { width: '100%', height: 200, backgroundColor: '#f0f0f0' },
  loadingContainer: { width: '100%', height: 200, justifyContent: 'center', alignItems: 'center', backgroundColor: '#f0f0f0' },
  errorContainer: { width: '100%', height: 200, justifyContent: 'center', alignItems: 'center', backgroundColor: '#ffebee' },
});
```

</details>

BREAKING CHANGE: minimum nitro-modules version is now 0.35.0

Closes: #169
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants